昨天那份成分表今天就收到帳,理由留到後面。今天動手時要多裝一個相依套件(jsdom),不想裝的話瀏覽器的開發者工具也走得完。中間有一輪用到第 26 天那個本機模型,那輪你不用跟著跑,看存檔就好。
第 26、27 天那兩輪模型報告,交出來的形狀都是一筆一筆列位置:這行危險、那個參數沒驗,每條有自己的編號跟行號。
那如果兩筆發現,其實指向同一條資料流呢?
第 26 天我把範例專案逐檔丟給本機模型看。輪到 src/render.js 那個檔,它回了兩條:
CWE-79 | line 4 | Sets innerHTML to user-supplied content without sanitization.
CWE-90 | line 9 | Calls an external API (ask) without validating or sanitizing its output.
第一條說這裡把東西塞進 innerHTML 沒有消毒。第二條說這裡呼叫了一個外部 API,沒有驗證也沒有消毒它的回傳。
兩條都看到了真的東西,只是類別跟行號都不可靠(後面會對)。而且那個檔只有十二行。
一條指的是不可信資料的來源,另一條指的是危險操作的終點。資料最後進到危險操作的那個位置,資安上叫 sink,底下我就這樣叫它。
看著這個十二行的檔案,我開始想:ask() 回來的那個東西,會不會一路流進 innerHTML?
報告本身回答不了這件事。它給的是兩個編號、兩個行號,沒有一格叫「這兩條有沒有關係」。
編號本身也別急著信。CWE-90 是 LDAP 注入(官方條目寫的是 Improper Neutralization of Special Elements used in an LDAP Query,2026-08 查證),跟這裡呼叫模型 API 沒有關係。模型確實看到 ask() 的回傳沒處理就送出去,只是把類別貼錯了。那個回傳會不會變成漏洞,還要看後面誰拿去用。
先把那條路上的兩支檔攤開。一支是 src/api.js,職責是呼叫模型:
const data = await res.json();
return data.choices[0].message.content;
照 API 文件寫的。另一支是 src/render.js,職責是把回答顯示出來:
const answer = await ask(question);
output.innerHTML = `<div class="answer">${answer}</div>`;
ask() 就是上面那支。
第 5 天我花了一整篇講 innerHTML 那行為什麼危險,所以這行對讀到這裡的你不新鮮。新鮮的是另一件事:這兩支檔的作者可以是兩個人,而每個人都只看得到自己那一半。
寫 api.js 的人交出的是「模型回什麼我就回什麼」,這是他被交代的事。寫 render.js 的人拿到一個叫 answer 的字串,看起來像自己系統的回傳值。
撇開第 5 天那張表就列過的前端金鑰(api.js 第 1 行那個 import.meta.env.VITE_MODEL_API_KEY),單看這兩支檔各自的職責,這條資料流不容易在程式碼審查裡被停下來問。
失守在交界:模型的輸出送到畫面上之前,沒有人把關。而模型讀什麼,第 11 天那篇已經演過一次,你看見的網頁,不是模型讀到的那一份。它可能剛讀完一段別人塞的字。

這張圖要看兩個地方。一是那兩個編號都掛在 render.js 上,報告把它們當成兩件事,其實講的是同一支檔裡相鄰的兩行。二是只有最下面那段粗線是今天真的打過的,虛線那幾段都還沒跑。
到這裡為止,我手上的是一個說得通的故事,而說得通跟走得通是兩件事。
第 25 天那篇的教訓就是這個形狀:一份自己說自己成立的紀錄,證明不了自己。 模型列的兩條加上我腦中把它們連起來的那條線,全部加起來還是零次執行。
所以我打一次。
判準要先想清楚,不然打完也分不出結果。
第 27 天那支確認腳本的第一版,我拿「輸出裡有沒有出現那串參數」當判準。結果 dig 解不出網域的時候,會把整串參數原樣印回錯誤訊息,修好的版本也被判成打進去了。
同一個坑在這裡的長相:render.js 組出來的字串裡當然找得到那個 onerror,那就是我餵進去的東西。字串裡出現它,只證明它被印出來了。
我要證的是另外兩件事:瀏覽器把它當成一個節點,而且那段 JS 真的跑。所以判準得放進 HTML 引擎裡:
import { JSDOM } from "jsdom";
const dom = new JSDOM(`<!DOCTYPE html><div id="answer"></div>`,
{ url: "https://victim.example/", runScripts: "dangerously" });
const { window } = dom;
// render.js 第 11 行那個 innerHTML,改成寫進這個 DOM
window.document.getElementById("answer").innerHTML = render(MODEL_ANSWER);
const img = window.document.querySelector("#answer img");
if (!img) return false; // 連節點都沒造出來,擋下了
img.dispatchEvent(new window.Event("error")); // 看 onerror 是不是可執行的 JS
const fired = Boolean(window.__xss);
這是 recipe 那支腳本裡判定那個函式的內容,所以 return 在函式裡,貼到最外層會是語法錯誤。它也不能省:修好版走到那裡 img 一定是空的,少了它下一行就對著空值呼叫 dispatchEvent。MODEL_ANSWER 是模擬模型吐出來的那句話,一個帶著 onerror 的 img 標籤;render() 有兩個版本,一個是 render.js 現在那行,一個是逃逸過的。跑起來:
有洞版 render.js 現況 造出 <img onerror>,派發 error 後 JS 真的執行了(XSS 成立)
修好版(逃逸) 沒造出可執行節點(只當文字顯示,擋下)
兩邊都要如預期才算數。只驗有洞版會中,我分不出「修法有效」跟「這一輪根本沒打進去」。
這一步要 jsdom,違反我前面幾天講的「今天不用裝東西」。不想裝的話,開一個空白分頁在開發者工具裡也走得完:拿掉 import 跟 new JSDOM(...),用那一頁的 document 自己造一個容器,再把那幾行包進函式呼叫。不要在你登入中的那個站上跑,那段 JS 會跑在那一頁的 origin 裡,碰得到那一頁碰得到的東西。
換到真的瀏覽器,判準要多看一格。你那一頁如果有 CSP 而且沒放行行內事件處理器,onerror 會被擋掉、window.__xss 不會亮,而那個 img 還在 DOM 裡,也就是這一層照樣把不可信的字串當標記解析,洞一點都沒補。第 5 天那篇兩種頁面都量過(沒有 CSP 是 fired=true,有 CSP 是 fired=false imgInDOM=true),所以要先看節點在不在,再看 window.__xss。空白分頁沒有 CSP,那一格的結果會跟 jsdom 一樣;jsdom 不實作 CSP,所以它永遠只有那一格。
jsdom 已經列在範例專案根目錄的 dependencies 裡,所以複製整份儲存庫之後在儲存庫根目錄跑 npm ci 就好。它釘的那版要 Node 22.22 以上(^22.22.2 || ^24.15.0 || >=26.0.0,我這台是 24.18.1)。不要在 recipe 目錄裡打 npm install jsdom:那裡沒有自己的 package.json,npm 會往上找到根目錄那一份,套件裝到那邊去,而那份要是還沒列 jsdom,它會順手加一行進去。
runScripts: "dangerously" 這個設定要小心,它比這一天的洞更容易傷到你自己。它會執行你餵進去的那段 JS,而 jsdom 不是沙箱:onerror 裡一行 this.constructor.constructor("return process")() 就摸得到跑這支腳本的 Node 行程,process.env 也在裡面。所以只餵你自己寫死的 payload,不要把線上模型回你的那串字丟進去跑。
開頭那兩筆,一筆指出來源,一筆指出危險終點,在那個檔裡是相鄰的兩行。它把兩半都看到了,交出來的卻是兩條各自獨立的問題。
第 27 天那輪換了個問法,我逐類問它「CWE-79 在哪個檔」。它指對了檔案,理由寫的是:
directly sets innerHTML from user input
檔案對了,來源說錯了。使用者打進輸入框的字是走另一條路送給模型的,而塞進 innerHTML 的那個東西是 ask() 回來的。
行號也對不上。第 26 天那輪說 sink 在 line 4、外部呼叫在 line 9,實際上是第 11 行跟第 10 行。這個我沒有用看的,檢查腳本裡那條是去數行號再比對的。
這兩輪拿到的清單就是切開來的:一條一個編號、一個行號、一句理由,沒有一格放得下「這兩條有沒有關係」。
上面那個結論是推的。第 26 天逐檔問、第 27 天逐類問,兩種問法都預設了答案落在單一個位置上。那答案是兩條獨立的問題,有可能是我的問法決定的,不是它的能力決定的。
所以我又跑了一輪。同一份模型、同樣 --temp 0,這次七個檔一次全給它,而且把要的東西講明:
Do not look for problems inside a single file. Instead, find pairs of files
where an untrusted value enters in one file and reaches a dangerous operation
in another.
八秒,它吐了兩行組合,逐字是這樣:
server/files.js -> server/tools.js | uploads/filename | reads arbitrary uploaded file contents into memory and executes system commands
</think>
server/files.js -> src/format.js | any formatPrice input (cents, currency) | formatPrice performs sensitive number formatting/rounding on untrusted values
中間那個 </think> 不是我貼漏了,存檔裡就長這樣。起始那半在提示裡:這個模型的對話樣板結尾就是 <|start_of_role|>assistant<|end_of_role|><think>,所以它一開口就在推理段,存檔只留生成的部分,看得到收尾看不到開頭。第一行落在推理裡,界線之後交出來的只有第二行。
不過兩行我都追過了,靜態引用這一層都接不起來。
要串成一條路,得先有一個值從這個檔真的傳到那個檔。而 server/tools.js 跟 src/format.js,那七個檔裡沒有任何一個 import 或 require 引用它們。不過我也只驗到這一層。 那七個檔裡沒有把 router 掛起來的進入點,而 files.js 寫的是絕對路徑 /var/app/uploads、tools.js 寫的是相對路徑 uploads/,名字一樣,是不是同一個目錄要看行程的工作目錄,那七個檔裡沒有一個設定它。所以繞檔案系統、或繞一個共同的呼叫端有沒有路,我沒有掃過。第一行表面上的共同線索就是那個目錄名。
那七個檔之間,檔對檔的引用(import 或 require 指到這七個檔裡的另一個)只有一條,src/render.js 的第 1 行:
import { ask } from "./api.js";
而這一條在程式碼上接得起來:render.js 匯入 ask(),第 10 行接住回傳,第 11 行交給 innerHTML,也就是前面那段 PoC 打的最後一步。唯一那條接得起來的路,正是它沒指出來的那一條。
這一輪我重跑了十次,紀錄裡那十個雜湊完全相同(存檔留的是雜湊,不是十份輸出)。所以這一段只證得到一件事:這個問法、這個模型,輸出穩定地就是那兩行。 換一種措辭問它會不會指到那條,我沒有掃過,那是另一天的事。
所以「模型提候選、人走一遍」這個分工,在今天這件事上不是效率考量。前兩輪它給我的是兩個座標,這一輪我給了它填組合的欄位,它填的是名字看起來像的兩個檔。
我上面引了三輪的輸出,而昨天那支對帳腳本問的就是:這句話是拿哪一份權重、哪一版提示、哪一支腳本跑出來的?
第 27 天那輪查得回執行條件,directly sets innerHTML from user input 那句的五格都對得上。今天這一輪是新腳本新提示,所以我讓它跑完自己把那幾樣寫進 run.json。第 26 天還沒有成分表這個東西,那兩條只有原始輸出,查不回它是拿什麼跑的。三輪都留了輸出存檔,附得上執行條件的是後面兩輪。
附得上帳離重驗得回去還差一截,昨天那篇自己講過:五格都對得上只代表這五樣沒變,不代表條件都一樣。明天要把每一列標成過了、沒過、沒測,這幾句會落在不同欄。
第一步是把手上的候選報告攤開來,找落在一個檔裡、或一條資料流上的兩條。挑的標準是「一條像入口、一條像出口」,不是挑嚴重度最高的那兩條。
第二步是問一句話:入口那條進來的東西,走得到出口那條嗎。
第三步就是打,而且打在你自己控制得住的地方:把那條資料流抄成一支獨立的腳本,或在本機開一份只有你連得到的實例。不要往共用環境或正式資料庫送,那個標記一旦被存下來就是一筆躺在真實資料裡的 payload,怎麼清掉也要一起寫進紀錄。
判準要挑一個「沒打進去的時候不會亮」的東西,我上面那個是 window.__xss。擋下來那一邊得跟著你的修法換:我用逃逸,所以「img 沒生出來」等於擋下了;改用 HTML 清理器的話 img 可能照樣在、只有 onerror 被拿掉,照抄我這條會把修對了的清理器判成沒過(第 5 天那篇踩過)。
結果就算是否定的,也不是白做。 但走不通不等於擋住了。先記下它停在哪一步,再逐一排掉:中間真的有一道你沒注意到的檢查、前面講的那格 CSP、payload 沒打對、那條路本來就不存在,或者模型這次就是沒有覆述那串字。不要直接記成「我有防線」。
範例專案這邊我得示範得出來,而走通的是出口那一半。api.js 那一端,還有模型讀到被汙染內容之後吐出那串字的那一步,今天都沒有執行過,那兩步在這裡還是推論。
innerHTML 會把字串當成標記解析,textContent 不會。這條分界第 5 天講過,今天不引入新的。上面 PoC 裡那個逃逸函式只是拿來對照的,它只管 HTML 文字這個位置,不是通用的清理器;換到屬性、網址、CSS 或 JS 裡面都不能照抄。
範例專案那條 sink 沒有真的修掉,因為那個專案是這個系列的答案卷,前面幾天還要拿它來掃。
比較值得說的是攻擊集。這個 payload 第 5 天就收過一條了,今天還要再收一條,因為載體不一樣。第 5 天那條標的是 dom,因為那天的測試向量是我自己在輸入框打進去的。那篇正文其實已經寫明「髒東西進來的位置是 answer,不是輸入框」,只是那天沒有真的從模型那一端走一次。今天這條標 model,補的就是那一段。
一樣的 sink、一樣那串字,但是掛在輸入框那條路上的檢查,這串字一道都不會經過,因為它不從那裡進來。 今天這一輪我沒有實際跑任何一道輸入檢查,所以這句講的是位置關係,不是量測結果。
判準沿用第 5 天那條:進 DOM 看節點跟屬性,不看畫面跳不跳。今天多的一步是派發 error,確認 onerror 是可執行的 JS,不只是一個躺在屬性裡的字串。
今天長的是第一份,21 條變 22 條,多的那條就是上面那個載體換掉的版本。
recipe 29 裡有九節檢查,其中一節要 node 跟 jsdom、一節要 python3 去數引用邊,其餘七節只要 shell,但要保留整份儲存庫的目錄結構:一節讀的是第 5 天那份 recipe 的目錄,一節讀的是儲存庫根目錄那張索引表,少了它們那兩節會判沒過,不是跳過。
我也把每一節各弄壞一次跑過,包括把判準換回字串比對那條,看它會不會被抓到。跑之前先確認工作區是乾淨的:那支腳本會改寫範例專案、兩份存檔跟索引表,靠 trap 在結束時還原,硬中斷就還原不回來。
明天是第 30 天。前面二十九天累積下來的那幾份東西要收成一張表:攻擊集、應放行集、回歸清單、候選標注表、成分表,加上今天這條鏈的紀錄。每一列分成過了、沒過、沒測。
今天這條要拆成兩列才誠實。「模型的回答進 innerHTML」那一段打過也擋過,算「過了」;整條從被汙染的內容一路到畫面,今天沒有跑,算「沒測」。而那兩條 CWE 的行號跟來源判斷,我還沒辦法說它們算過還是算沒過。明天得決定這種東西該放哪一欄。